package com.sandwormz.webcompiler;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.javascript.jscomp.CheckLevel;
import com.google.javascript.jscomp.CompilationLevel;
import com.google.javascript.jscomp.Compiler;
import com.google.javascript.jscomp.CompilerOptions;
import com.google.javascript.jscomp.DiagnosticGroups;
import com.google.javascript.jscomp.Result;
import com.google.javascript.jscomp.SourceFile;
import com.sandwormz.webcompiler.resources.ResourceNotLoaded;

/**
 * A page is the unit of compilation. It is created from an html page with
 * custom markup contained in html comments. When compiled it produces a
 * (optionally minified) page according to the instructions in those comments.
 * Examples of these comments are found in src/test/webapp/
 * 
 * @author mbryant
 * 
 */
public class Page {

    protected class AnnotationMap {
        private Map<AnnotationType, LinkedList<Annotation>> annotations =
                new HashMap<AnnotationType, LinkedList<Annotation>>();

        public List<Annotation> get(final AnnotationType type) {
            LinkedList<Annotation> annotationList = this.annotations.get(type);
            if (null == annotationList) {
                annotationList = new LinkedList<Annotation>();
            }
            return annotationList;
        }

        public void put(final AnnotationType type, final Annotation annotation) {
            LinkedList<Annotation> annotationList = this.annotations.get(type);
            if (null == annotationList) {
                annotationList = new LinkedList<Annotation>();
                this.annotations.put(type, annotationList);
            }
            annotationList.add(annotation);
        }

        @Override
        public String toString() {
            return this.annotations.toString();
        }
    }

    protected class AnnotationToken extends Token {
        protected String annotationName;

        protected int annotationStart;
        protected int annotationEnd;

        protected boolean isEndToken;

        @Override
        public String toString() {
            if (this.isEndToken) {
                return this.annotationName + "_end";
            } else {
                return this.annotationName;
            }
        }

        @SuppressWarnings("synthetic-access")
        @VisibleForTesting
        protected String getAnnotationContent() {
            return Page.this.file.getContent().substring(this.annotationStart,
                    this.annotationEnd);
        }
    }

    protected class NamedBlockMap {
        Map<String, List<SourceFile>> map =
                new HashMap<String, List<SourceFile>>();

        /**
         * @param name
         * @return
         */
        public boolean containsNamedBlock(final String name) {
            return this.map.containsKey(name);
        }

        /**
         * @param externBlockName
         * @return
         */
        public List<SourceFile> get(final String name) {
            List<SourceFile> sourceFiles = this.map.get(name);
            if (null == sourceFiles) {
                sourceFiles = new LinkedList<SourceFile>();
            }
            return sourceFiles;
        }

        /**
         * @param name
         * @param sourceFiles
         */
        public void put(final String name, final List<SourceFile> sourceFiles) {
            this.map.put(name, sourceFiles);
        }
    }

    protected class TextElement {
        protected int start;
        protected int end;
        protected String content;

        @Override
        public String toString() {
            return "[TextElement]";
        }
    }

    protected class TextToken extends Token {

        @Override
        public String toString() {
            return "[TextToken]";
        }

        @SuppressWarnings("synthetic-access")
        @VisibleForTesting
        protected String getValue() {
            return Page.this.file.getContent().substring(this.start, this.end);
        }
    }

    protected class Token {
        protected int start;
        protected int end;

        @SuppressWarnings("synthetic-access")
        @VisibleForTesting
        protected String getContent() {
            return Page.this.file.getContent().substring(this.start, this.end);
        }
    }

    private static final Logger _logger = LoggerFactory.getLogger(Page.class);

    /**
     * @param filepath
     * @return
     */
    private static File getFile(final String filepath) {
        File file = new File(filepath);
        return file;
    }

    /**
     * Returns a list of SourceFiles if given a list of relative or absolute
     * file names. Directories are traversed recursively.
     * 
     * @param filePaths
     * @return
     * @throws FileNotFoundException
     */
    private static List<SourceFile> getSourceFilesFromPaths(
            final List<String> filePaths) throws FileNotFoundException {
        Preconditions.checkNotNull(filePaths, "Filepaths are required.");

        List<SourceFile> files = new LinkedList<SourceFile>();
        for (String filepath : filePaths) {
            if (filepath.toLowerCase().startsWith("http")) {
                try {
                    URL fileUrl = new URL(filepath);
                    files.add(new SourceFile.Builder().buildFromInputStream(
                            filepath, fileUrl.openStream()));
                } catch (MalformedURLException e) {
                    throw new PageCompilerError(
                            "Failed to create an URL for some unexpected reason.",
                            e);
                } catch (FileNotFoundException e) {
                    throw e;
                } catch (IOException e) {
                    throw new PageCompilerError(
                            "Failed to fetch the sourcefiles from paths for some unexpected reason.",
                            e);
                }

            } else {
                File file = getFile(filepath);
                files.addAll(sourcefileFromFile(file));
            }
        }
        return files;
    }

    /**
     * Fetch a list of SourceFiles from a file. If the file is a directory, it
     * is traversed recursively. If not, a single SourceFile is returned.
     * 
     * @param file
     * @return
     */
    private static List<SourceFile> sourcefileFromFile(final File file) {
        Preconditions.checkNotNull(file, "The file may not be null.");
        Preconditions.checkArgument(file.exists(),
                "The file " + file.getAbsolutePath() + " must exist.");
        List<SourceFile> sourceFiles = new LinkedList<SourceFile>();
        if (file.isDirectory()) {
            for (File child : file.listFiles()) {
                sourceFiles.addAll(sourcefileFromFile(child));
            }
        } else {
            sourceFiles.add(SourceFile.fromFile(file));
        }
        return sourceFiles;
    }

    // private final String fileContents;

    // private final String rootPath;

    private LinkedList<TextElement> textElements;

    private ResourceWithContents file;

    // splits an annotation into its component parts
    protected static final Pattern annotationPartsPattern =
            Pattern.compile(
                    "([a-zA-Z0-9]*?)[\\s]*=[\\s]*(\".*?\")?(([a-zA-Z0-9_-]*?)(?:[$>\\s\\n\\r\\)]+))?",
                    Pattern.MULTILINE);

    protected static final Pattern commentPattern = Pattern.compile(
            "\\<!\\-\\-([\\s\\S]*?)\\-\\->", Pattern.MULTILINE);

    public Page(final ResourceWithContents file) {

        this.file = file;

        // this.rootPath = file.getParentPath();
        // this.fileContents = file.getContent();
    }

    /**
     * Compile the page and return a list of FileWithContents objects to be
     * written to disk. The first such object will be the compiled source html
     * followed by all compiled or copied resources generated from it.
     * 
     * @return
     * @throws Exception
     */
    public List<ResourceWithContents> compile() throws Exception {
        List<ResourceWithContents> outputFiles =
                new LinkedList<ResourceWithContents>();

        _logger.info("compiling from: " + this.file.getPath());

        Annotation lastAnnotation = null;
        try {

            List<Token> tokens = tokenize();
            AnnotationMap annotations = parseAnnotations(tokens);

            List<Annotation> inlines =
                    annotations.get(AnnotationType.CompilePage);
            Preconditions.checkState(inlines.size() == 1,
                    "Expected to find 1 CompilePage annotation, but found "
                            + inlines.size());

            Annotation compilePageAnnotation = inlines.get(0);

            String outputFile = compilePageAnnotation.getOption("outputFile");
            Preconditions
                    .checkState(outputFile != null,
                            "outputFile is a required option in the CompilePage annotation.");

            PathTranslator filePathTranslator =
                    PathTranslator.Builder()
                            .parentDirectory(this.file.getParentPath()).build();
            for (Annotation annotation : annotations
                    .get(AnnotationType.SourcePathTranslation)) {
                lastAnnotation = annotation;
                Preconditions
                        .checkArgument(annotation.hasOption("regex"),
                                "regex is a required option for the SourcePathTranslation tag.");
                Preconditions
                        .checkArgument(annotation.hasOption("replacement"),
                                "replacement is a required option for the SourcePathTranslation tag.");
                filePathTranslator.addPathTranslation(
                        annotation.getOption("regex"),
                        annotation.getOption("replacement"));
            }

            // named blocks
            NamedBlockMap namedBlockMap = new NamedBlockMap();
            for (Annotation annotation : annotations
                    .get(AnnotationType.ClosureExtern)) {
                lastAnnotation = annotation;
                Preconditions.checkArgument(annotation.hasOption("name"),
                        "name is a required option for the ClosureExtern tag.");
                String name = annotation.getOption("name");
                Preconditions.checkArgument(
                        !namedBlockMap.containsNamedBlock(name),
                        "Duplicated named block tag found for name " + name);

                List<String> sourceFilePaths =
                        filePathTranslator
                                .findAllJavscriptPathsInFragment(annotation
                                        .getContainedTextElements());

                List<SourceFile> sourceFiles =
                        Page.getSourceFilesFromPaths(sourceFilePaths);
                namedBlockMap.put(name, sourceFiles);
            }

            for (Annotation annotation : annotations
                    .get(AnnotationType.CopyFiles)) {
                lastAnnotation = annotation;
                outputFiles.addAll(doCopy(filePathTranslator, annotation));
            }

            for (Annotation annotation : annotations
                    .get(AnnotationType.RemoveContent)) {
                lastAnnotation = annotation;
                List<TextElement> textElements =
                        annotation.getContainedTextElements();
                for (TextElement textElement : textElements) {
                    textElement.content = "";
                }
            }

            // compilations:
            for (Annotation annotation : annotations
                    .get(AnnotationType.ClosureCompilation)) {
                lastAnnotation = annotation;
                ResourceWithContents compiledFile =
                        this.doCompilation(annotation, namedBlockMap,
                                filePathTranslator);
                outputFiles.add(compiledFile);

            }

            lastAnnotation = null;

            StringBuilder content = new StringBuilder();
            for (TextElement textElement : this.textElements) {
                content.append(textElement.content);
            }

            outputFile = filePathTranslator.translatePath(outputFile);

            ResourceWithContents compiledTarget =
                    ResourceWithContents.Builder().path(outputFile)
                            .content(content.toString()).build();

            if (compilePageAnnotation.hasOption("minify")) {
                _logger.info("minifying...");
                compiledTarget.setContent(compiledTarget.getContent()
                        .replaceAll("(>)(\\s+)", "$1"));
                compiledTarget.setContent(compiledTarget.getContent()
                        .replaceAll("(?s)<!--(.*?)-->", ""));
                compiledTarget.setContent(compiledTarget.getContent()
                        .replaceAll("[\\n\\r]\\2+", "\\n"));
            }

            outputFiles.add(0, compiledTarget);

            return outputFiles;
        } catch (Exception e) {
            if (null == lastAnnotation) {
                throw new PageCompilerException(e);
            } else {
                throw new PageCompilerException(lastAnnotation.getLine(), e);
            }
        }
    }

    /**
     * @param compilationUnits
     */
    public void writeFiles(final List<ResourceWithContents> compilationUnits) {
        for (ResourceWithContents file : compilationUnits) {
            _logger.info("writing: " + file.getPath());
            file.write();
        }
    }

    /**
     * @param filePathTranslator
     * @param namedBlockMap
     * @param annotation2
     * @throws ResourceNotLoaded
     */
    @SuppressWarnings("null")
    protected ResourceWithContents doCompilation(final Annotation annotation,
            final NamedBlockMap namedBlockMap,
            final PathTranslator filePathTranslator)
            throws FileNotFoundException, ResourceNotLoaded {

        Preconditions.checkState(annotation != null,
                "An annotation is required.");
        Preconditions.checkState(namedBlockMap != null,
                "A namedBlockMap is required.");
        Preconditions.checkState(filePathTranslator != null,
                "A filePathTranslator is required.");
        Preconditions.checkState(
                annotation.getContainedTextElements().size() > 0,
                "There must be at lest one text element in the compilation.");

        String externBlockAttribute = annotation.getOption("externBlocks");

        List<SourceFile> externs;
        if ((null == externBlockAttribute) || (externBlockAttribute.isEmpty())) {
            externs = new LinkedList<SourceFile>();
        } else {
            externs = new LinkedList<SourceFile>();
            String[] externBlocks = externBlockAttribute.split(" ");
            for (String externBlockName : externBlocks) {
                List<SourceFile> externAnnotation =
                        namedBlockMap.get(externBlockName);
                Preconditions.checkState(externAnnotation != null,
                        "Expected to find a named block titled "
                                + externBlockName);
                externs.addAll(externAnnotation);
            }
        }

        List<String> sourceFilePaths =
                filePathTranslator.findAllJavscriptPathsInFragment(annotation
                        .getContainedTextElements());
        List<SourceFile> source = Page.getSourceFilesFromPaths(sourceFilePaths);

        String compilationLevelString =
                annotation.getOption("compilationLevel");

        CompilationLevel compilationLevel = null;
        try {
            compilationLevel = CompilationLevel.valueOf(compilationLevelString);
            _logger.debug("Using compilation level: " + compilationLevelString);
        } catch (IllegalArgumentException e) {
            throw new PageCompilerError(
                    annotation.getLine(),
                    "Compilation level invalid: "
                            + compilationLevelString
                            + ". Acceptable values are: WHITESPACE_ONLY, SIMPLE_OPTIMIZATIONS, and ADVANCED_OPTIMIZATIONS.",
                    e);
        }

        CompilerOptions compilerOptions = new CompilerOptions();

        String outputFile = annotation.getOption("outputFile");
        String translatedOutputPath =
                filePathTranslator.translatePath(outputFile);
        _logger.info("compiling to: " + translatedOutputPath);

        if (annotation.hasOptionSet("prettyPrint")) {
            _logger.debug("compilation output will be pretty printed.");
            compilerOptions.setPrettyPrint(true);
        }

        compilerOptions.setWarningLevel(DiagnosticGroups.EXTERNS_VALIDATION,
                CheckLevel.OFF);

        if (annotation.hasOptionSet("debug")) {
            _logger.debug("compilation will use debug options");
            compilationLevel
                    .setDebugOptionsForCompilationLevel(compilerOptions);
        } else {
            compilationLevel.setOptionsForCompilationLevel(compilerOptions);
        }

        Compiler compiler = new Compiler();

        Result result = compiler.compile(externs, source, compilerOptions);

        if (!result.success) {
            _logger.error("Failed to compile to " + translatedOutputPath);
        }

        List<TextElement> textElements = annotation.getContainedTextElements();
        String outputContent =
                "<script type=\"text/javascript\" src=\""
                        + annotation.getOption("outputFile") + "\"></script>";

        for (TextElement textElement : textElements) {
            textElement.content = outputContent;
            outputContent = "";
        }

        ResourceWithContents compiledTarget =
                ResourceWithContents.Builder().path(translatedOutputPath)
                        .content(compiler.toSource()).build();

        return compiledTarget;
    }

    /**
     * Produce a list of resources for a given copy annotation.
     * 
     * @param filePathTranslator
     * @param annotation
     */
    protected List<ResourceWithContents> doCopy(
            final PathTranslator filePathTranslator, final Annotation annotation) {
        Preconditions
                .checkArgument(annotation.hasOption("outputDirectory"),
                        "outputDirectory is a required option for the CopyFiles annotation.");

        // TODO: verify works with css, remote files via http
        List<ResourceWithContents> copiedFiles =
                new LinkedList<ResourceWithContents>();
        StringBuilder outputContent = new StringBuilder();

        String outputDirectory = annotation.getOption("outputDirectory");
        String translatedOutputPath =
                filePathTranslator.translatePath(outputDirectory);
        List<String> sourceFilePaths =
                filePathTranslator.findAllJavscriptPathsInFragment(annotation
                        .getContainedTextElements());
        for (String sourceFile : sourceFilePaths) {

            ResourceWithContents fileToCopy;
            try {
                fileToCopy =
                        ResourceWithContents
                                .Builder()
                                .path(filePathTranslator
                                        .translatePath(sourceFile)).build();
            } catch (ResourceNotLoaded e) {
                // TODO: files that can't be found here or in the doCompile
                // method
                // should be handled and reported in a more consistent way.
                throw new PageCompilerError(e);
            }
            fileToCopy.moveToDirectory(translatedOutputPath);

            outputContent.append("<script type=\"text/javascript\" src=\""
                    + outputDirectory + fileToCopy.getFileName()
                    + "\"></script>");

            copiedFiles.add(fileToCopy);
        }

        List<TextElement> textElements = annotation.getContainedTextElements();
        for (TextElement textElement : textElements) {
            textElement.content = outputContent.toString();
            outputContent = new StringBuilder();
        }

        return copiedFiles;
    }

    /**
     * @param content
     * @return
     */
    @VisibleForTesting
    protected Map<String, String> extractAnnotationOptions(final String content) {

        Map<String, String> options = new HashMap<String, String>();
        if (null != content) {
            Matcher matcher = annotationPartsPattern.matcher(content);
            while (matcher.find()) {
                String key = matcher.group(1);

                String quotedValue = matcher.group(2);
                String value = matcher.group(4);

                if ((null != quotedValue) && (!quotedValue.isEmpty())) {
                    int length = quotedValue.length();
                    options.put(key, quotedValue.substring(1, length - 1));
                } else if (null != value) {
                    options.put(key, value.trim());
                }
            }

        }
        return options;
    }

    protected List<Token> extractTokens(final int start, final int end) {

        Token lastToken = null;
        try {
            String commentGroup = this.file.getContent().substring(start, end);
            LinkedList<Token> tokens = new LinkedList<Token>();

            // protected static final
            Pattern annotationPattern =
                    Pattern.compile(
                            "@([a-zA-Z0-9]*)(_end)?(\\([\\s\\S]*?\\))?",
                            Pattern.MULTILINE);

            Matcher matcher = annotationPattern.matcher(commentGroup);

            while (matcher.find()) {
                String annotationName = matcher.group(1);
                String annotationEnd = matcher.group(2);

                AnnotationToken token = new AnnotationToken();
                lastToken = token;
                token.start = start;
                token.end = end;

                token.annotationStart = start + matcher.start(1);
                if (matcher.group(3) != null) {
                    token.annotationEnd = start + matcher.end(3);
                } else if (matcher.group(2) != null) {
                    token.annotationEnd = start + matcher.end(2);
                } else {
                    token.annotationEnd = start + matcher.end(1);
                }

                token.annotationName = annotationName;
                token.isEndToken = (annotationEnd != null);
                tokens.add(token);

            }

            return tokens;

        } catch (PageCompilerError e) {
            throw e;
        } catch (Exception e) {
            if (null == lastToken) {
                throw new PageCompilerError(e);
            } else {
                throw new PageCompilerError(
                        this.findLineAtIndex(lastToken.start), e);
            }
        }

    }

    /**
     * Given a list of tokens for a page, produce the map of annotations.
     * 
     * @param tokens
     */
    @VisibleForTesting
    protected AnnotationMap parseAnnotations(final List<Token> tokens) {

        // text elements are what we will ultimately end up returning
        // as the new content for the page
        LinkedList<TextElement> textElements = new LinkedList<TextElement>();

        // we will process these to do the compilations and modify their
        // contained
        // text elements
        AnnotationMap annotations = new AnnotationMap();

        // contains all currently open annotations;
        LinkedList<Annotation> annotationStack = new LinkedList<Annotation>();

        for (Token token : tokens) {

            if (token instanceof TextToken) {
                String content =
                        this.file.getContent()
                                .substring(token.start, token.end);

                TextElement element = new TextElement();
                element.start = token.start;
                element.end = token.end;
                element.content = content;

                textElements.add(element);
                for (Annotation annotation : annotationStack) {
                    annotation.addTextElement(element);
                }
            } else if (token instanceof AnnotationToken) {
                AnnotationToken annotationToken = (AnnotationToken) token;

                String content =
                        this.file.getContent().substring(
                                annotationToken.annotationStart,
                                annotationToken.annotationEnd);

                if (annotationToken.isEndToken) {
                    Annotation openAnnotation = annotationStack.removeLast();
                    String expectedAnnotation = annotationToken.annotationName;

                    Preconditions
                            .checkState(
                                    openAnnotation.getName().equals(
                                            expectedAnnotation),
                                    "Expected " + expectedAnnotation
                                            + "_end but got "
                                            + openAnnotation.getName() + "_end");

                    annotations.put(openAnnotation.getType(), openAnnotation);
                } else {
                    Annotation openAnnotation =
                            Annotation.Builder()
                                    .line(this.findLineAtIndex(token.start))
                                    .name(annotationToken.annotationName)
                                    .options(extractAnnotationOptions(content))
                                    .build();
                    annotationStack.addLast(openAnnotation);
                }
            }
        }
        // clean up any unclosed annotations, if they are self closing
        for (int i = 0; i < annotationStack.size(); i++) {
            Annotation unclosedAnnotation = annotationStack.get(i);
            if (unclosedAnnotation.getType().isSelfClosing()) {
                annotationStack.remove(unclosedAnnotation);
                annotations.put(unclosedAnnotation.getType(),
                        unclosedAnnotation);
                i--;
            }
        }
        if (!annotationStack.isEmpty()) {
            for (Annotation unclosedAnnotation : annotationStack) {
                _logger.error("Expected " + unclosedAnnotation.getName()
                        + "_end but reached end of document.");
            }
            throw new PageCompilerError("Some annotations were not closed.");
        }

        this.textElements = textElements;
        return annotations;
    }

    @VisibleForTesting
    protected List<Token> tokenize() throws Exception {

        Token lastToken = null;
        int lastEnd = 0;
        try {

            Matcher matcher = commentPattern.matcher(this.file.getContent());

            LinkedList<Token> tokens = new LinkedList<Token>();
            while (matcher.find()) {
                int start = matcher.start();
                int end = matcher.end();

                TextToken beforeToken = new TextToken();
                beforeToken.start = lastEnd;
                beforeToken.end = start;
                tokens.add(beforeToken);

                tokens.addAll(this.extractTokens(start, end));

                lastEnd = end;
                lastToken = beforeToken;
            }
            if (lastEnd < this.file.getContent().length()) {
                TextToken token = new TextToken();
                token.start = lastEnd;
                token.end = this.file.getContent().length();

                tokens.add(token);

                lastToken = token;
            }
            return tokens;

            // } catch (PageCompilerException e) {
            // throw e;
        } catch (PageCompilerError e) {
            throw e;
        } catch (RuntimeException e) {
            if (null == lastToken) {
                throw new PageCompilerError(e);
            } else {
                throw new PageCompilerError(
                        this.findLineAtIndex(lastToken.start), e);
            }
        } catch (Exception e) {
            if (null == lastToken) {
                throw new PageCompilerException(e);
            } else {
                throw new PageCompilerException(
                        this.findLineAtIndex(lastToken.start), e);
            }
        }
    }

    /**
     * @param start
     * @return
     */
    private int findLineAtIndex(final int index) {
        int count =
                StringUtils.countMatches(
                        this.file.getContent().substring(0, index), "\n");

        // add one, because we are at line 1 before the first new line.
        return count + 1;
    }

}
